TCP 流量控制与拥塞控制

在TCP协议中,客户端发送的每一个包,服务端都应该有一个回复,如果服务端超过一定的时间没有回复,客户端就会重新发送这个包,直到有回复。

这个发送应答的过程是什么样呢?可以是上一个收到了应答,再发送下一个,这种模式有点像两个人直接打电话,你一句,我一句。但是这种方式的缺点是效率比较低。如果一方在电话那头处理的时间比较长,这一头就要干等着,双方都没办法干其他事情。

TCP 协议使用的也是同样的模式。为了保证顺序性,每一个包都有一个 ID。在建立连接的时候,会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但是这个应答也不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式称为累计确认或者累计应答。

为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别都有缓存来保存这些记录。发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分。

  • 第一部分:发送了并且已经确认的。这部分就是你交代下属的,并且也做完了的,应该划掉的。
  • 第二部分:发送了并且尚未确认的。这部分是你交代下属的,但是还没做完的,需要等待做完的回复之后,才能划掉。
  • 第三部分:没有发送,但是已经等待发送的。这部分是你还没有交代给下属,但是马上就要交代的。
  • 第四部分:没有发送,并且暂时还不会发送的。这部分是你还没有交代给下属,而且暂时还不会交代给下属的。

到底员工能同时处理多少事情呢,TCP里,接收端会给发送端报一个窗口的大小,叫Advertised window 这个窗口的大小应该等于上面第二部分加上第三部分,就是已经交代的加上马上要交代的,超过这个窗口的,接收端做不过来,就不能发送了。

于是,发送端需要保持下面的数据结构

  • LastByteAcked: 第一部分第二部分的分界线
  • LastByteSent : 第二部分和第三部分的分界线
  • LastByteAcked+AdvertisedWindow: 第三部分和第四部分的分界线

对于接收端,缓存里简单一点,

  • 第一部分,接受并且确认过的,也就是领导交代我,并且我做完的
  • 第二部分,还没接受,但是马上就能接收的,也就是我自己能够接收的最大工作量。
  • 第三部分 还没接受,也没法接受的,也就是超过工作量的部分,实在做不完。

数据结构是下面这样

  • MaxRecBuffer 最大缓存的量
  • LastByteRead 之后是已经接受了,但是还没被应用层读取的
  • NextByteExpected 是第一部分第二部分的分界线。

流量控制

在对于包的确认中,同时会携带一个窗口的大小

先假设窗口不变的情况下,窗口始终为9,4的确认来的时候,会右移一个,这个时候第13个包也可以发送了。

这时,假如发送端发送过猛,会将第三部分的10 11 12 13 全部发送完毕,之后就停止发送了,未发送可发送部分为0.

当对于包5的确认到达的时候,在客户端相当于窗口再滑动了一格,这时,才可以有更多的包可以发送了,例如第14个包才可以发送

如果接收方实在处理的慢,导致缓存中没空间了,可以通过确认信息修改窗口的大小,甚至可以设置为0.则发送方暂时停止发送。

假设,接收端的应用一直不读取缓存的数据,当数据包6确认后,串口大小就不再是9了,就要缩小为8.

这个新的窗口8通过6的确认消息到达发送端的时候,窗口没有平行右移,而是仅仅左面的边右移了,窗口由9变为8

如果接受端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到为0.

当这个窗口通过包14的确认到达发送端的时候,发送端窗口也调整为0 停止发送。

如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止低能窗口综合征,别空出一个字节来就赶快告诉发送方,然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口。

拥塞控制

也是通过窗口的大小来控制的,前面的滑动窗口rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口cwnd, 是怕把网络塞满。

LastByteSent-LastByteAcked<=min{cwnd,rwnd}, 是拥塞窗口和滑动窗口共同控制发送的速度。

TCP的拥塞控制就是在不堵塞,不丢包的情况下,尽量发挥带宽。

水管有粗细,网络有带宽,也即每秒钟能够发送多少数据;水管有长度,端到端有时延,在理想状态下,水管里的水= 水管粗细 水管长度,对于到网络上,通道的容量=带宽往返延迟。

如果设置发送窗口,使得发送但未确认的包为通道的容量,就能够撑满整个管道。

如图所示,假设往返时间为 8s,去 4s,回 4s,每秒发送一个包,每个包 1024byte。已经过去了 8s,则 8 个包都发出去了,其中前 4 个包已经到达接收端,但是 ACK 还没有返回,不能算发送成功。5-8 后四个包还在路上,还没被接收。这个时候,整个管道正好撑满,在发送端,已发送未确认的为 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以来回时间 8s。

如果在这个基础上再调大窗口,使得单位时间内更多的包可以发送,会怎么样呢?

我们来想,原来发送一个包,从一端到达另一端,假设一共经过四个设备,每个设备处理一个包时间耗费 1s,所以到达另一端需要耗费 4s,如果发送的更加快速,则单位时间内,会有更多的包到达这些中间设备,这些设备还是只能每秒处理一个包的话,多出来的包就会被丢弃,这是我们不想看到的。

这个时候,我们可以想其他的办法,例如这个四个设备本来每秒处理一个包,但是我们在这些设备上加缓存,处理不过来的在队列里面排着,这样包就不会丢失,但是缺点是会增加时延,这个缓存的包,4s 肯定到达不了接收端了,如果时延达到一定程度,就会超时重传,也是我们不想看到的。

于是 TCP 的拥塞控制主要来避免两种现象,包丢失和超时重传,。一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?

如果我们通过漏斗往瓶子里灌水,我们就知道,不能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢的倒,然后发现总能够倒进去,就可以越倒越快。这叫作慢启动。

一条 TCP 连接开始,cwnd 设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。可以看出这是指数级的增长

涨到什么时候是个头呢?有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。

每收到一个确认后,cwnd 增加 1/cwnd,我们接着上面的过程来,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。

但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。

拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。这真是一旦超时重传,马上回到解放前。但是这种方式太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿。

前面的快速重传算法中。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速的重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。

就像前面说的一样,正是这种知进退,使得时延很重要的情况下,反而降低了速度。但是如果你仔细想一下,TCP 的拥塞控制主要来避免的两个现象都是有问题的。

第一个问题是是丢包并不代表着通道满了,也可能是管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞了,退缩了,其实是不对的。

第二个问题是 TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满。

为了优化这两个问题,后来有了TCP BBR 拥塞算法。。它企图找到一个平衡点,就是通过不断的加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。